Appearance
背景
在 Vue3 + Pinia 的大型项目中,随着业务复杂度增长,单一的巨型 store 会带来:
- 状态耦合:无关状态变化导致组件不必要的重渲染
- 维护困难:数千行代码的 store 难以理解和修改
- 协作冲突:多人同时修改同一个 store 容易产生冲突
- 测试复杂:巨大的状态面导致单测编写困难
大厂的实践是:"按领域/功能切分多个小 store + 聚合门面(facade)",而不是做"嵌套子 store(Pinia 不支持树状嵌套)"。
主流实践模式
1. 按领域拆分(Domain-first)
例如 account/、symbol/、trade.tabs/、trade.orders/、trade.positions/、user/、security/ 等,每个 store 只关心自己的状态与动作。
好处:
- ✅ 边界清晰、并行开发容易
- ✅ 单测成本低
- ✅ 状态变更影响范围可控
2. 门面/聚合层(Facade/Aggregator)
在 trade/ 下提供一个 useTradeStore 做只读转发与跨模块编排(不写具体业务),对外保持原有 API,便于渐进式重构。
好处:
- ✅ 外部调用点无需大改
- ✅ 内部可持续拆分
- ✅ 提供统一的业务入口
3. 按"页面/功能"配对 store(Feature-scoped)
页面有独立的 feature store(如 trade.tabs),同时复用领域 store(如 account、symbolInfo)。
好处:
- ✅ 页面复杂交互不污染领域 store
- ✅ 功能模块可以独立开发和测试
4. 禁止隐性耦合与循环依赖
规则:
- store 之间只通过方法调用/只读数据交互,不在
state里保存对方的可变引用 - 避免:A 在
setup时useB,B 又在setup时useA - 把跨模块副作用放到门面层或独立 composable(如
useTradeBootstrap())里
反例:
ts
// ❌ 不要这样做
export const useStoreA = defineStore("storeA", () => {
const storeB = useStoreB(); // 在 setup 时就相互引用
// ...
});
export const useStoreB = defineStore("storeB", () => {
const storeA = useStoreA(); // 循环依赖
// ...
});正例:
ts
// ✅ 在动作方法中按需调用
export const useStoreA = defineStore("storeA", () => {
const doSomething = () => {
const storeB = useStoreB(); // 在需要时才引用
storeB.update();
};
return { doSomething };
});模块依赖最佳实践:外用 barrel、内走相对
为了彻底杜绝循环依赖,遵循以下可落地的"小规则":
1. 单向依赖原则
子模块 → 子模块:使用相对路径import { useXxxStore } from './xxx'或'../xxx'门面(aggregate/facade) → 子模块:相对路径'./tabs.store'- 禁止
子模块 → barrel(会导致 A→barrel→A 自咬尾)
示例:
ts
// ✅ 子模块之间:使用相对路径
// store/trade/tabs.store.ts
import { useTradeOrdersStore } from "./orders.store";
// ✅ 门面引用子模块:相对路径
// store/trade/index.ts
import { useTradeTabsStore } from "./tabs.store";
import { useTradeOrdersStore } from "./orders.store";
// ❌ 子模块引用 barrel:禁止!
// store/trade/tabs.store.ts
import { useTradeStore } from "@/store"; // 会造成循环依赖2. barrel (index.ts) 最小化
- 只
export门面:export * from './trade' - 不要在 barrel 里
export * from './trade/tabs.store'(暴露内部实现)
ts
// ✅ store/index.ts - 只导出门面
export { useAccountStore } from "./account";
export { useSymbolInfoStore } from "./symbol-info";
export { useTradeStore } from "./trade"; // 只导出聚合门面
export { useSocketStore } from "./socket";
// ❌ 不要导出内部模块
export { useTradeTabsStore } from "./trade/tabs.store"; // 不推荐3. storeToRefs 正确姿势
ts
import { storeToRefs } from "pinia";
// ✅ 正确:先实例化,再解构
const accountStore = useAccountStore();
const { selectedAccount } = storeToRefs(accountStore);
// ❌ 错误:直接解构会丢失响应性
const { selectedAccount } = useAccountStore(); // 不是响应式的4. 若必须跨域调用,优先经"服务层"
把流程编排放到 services/trade.service.ts,避免 store 之间互相 import 过多。
ts
// services/trade.service.ts
import { useAccountStore } from "@/store/account";
import { useTradeTabsStore } from "@/store/trade/tabs.store";
import { useSocketStore } from "@/store/socket";
export class TradeService {
// 跨 store 的复杂流程编排
async initializeTrade(symbol: string) {
const accountStore = useAccountStore();
const tabsStore = useTradeTabsStore();
const socketStore = useSocketStore();
// 编排多个 store 的动作
await accountStore.ensureAccount();
tabsStore.addTab({ symbol });
socketStore.setPinned(symbol);
}
}
export const tradeService = new TradeService();5. 工具护栏(可选但推荐)
通过 ESLint 自动化执行"外用 barrel、内走相对"的约束,从源头避免循环依赖。
安装依赖
bash
# 复杂逻辑:import 规则 + TS 解析器 + boundaries 插件
pnpm add -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin \
eslint-plugin-import eslint-import-resolver-typescript \
eslint-plugin-boundaries完整 ESLint 配置
js
// .eslintrc.cjs
/* 复杂逻辑:基础解析与插件启用(TS + import + boundaries) */
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "import", "boundaries"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
],
settings: {
/* 复杂逻辑:让 import 解析 TS 路径别名(含 tsconfig paths) */
"import/resolver": {
typescript: {
// 对应你的 tsconfig.json(支持 "paths" 别名)
project: "./tsconfig.json",
},
node: {
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
},
},
/* 复杂逻辑:定义"边界元素类型",用于约束依赖方向 */
"boundaries/elements": [
// 整个 store 的对外聚合入口(barrel)
{ type: "store-barrel", pattern: "src/store/index.ts" },
// 各领域门面(facade),如 src/store/trade/index.ts
{ type: "store-facade", pattern: "src/store/**/index.ts" },
// 具体子模块实现(除 index.ts 外的 store 文件)
{ type: "store-module", pattern: "src/store/**/!(*index).ts" },
// 其它层(按需添加)
{ type: "feature", pattern: "src/**/features/**" },
{ type: "ui", pattern: "src/**/components/**" },
],
},
rules: {
/* 复杂逻辑:阻止循环依赖(忽略三方包) */
"import/no-cycle": ["error", { ignoreExternal: true, maxDepth: 1 }],
/* 复杂逻辑:确保上面 elements 定义能被识别 */
"boundaries/no-unknown": "error",
/* 复杂逻辑:默认禁止跨边界依赖,仅按规则白名单放行 */
"boundaries/element-types": [
"error",
{
default: "disallow",
rules: [
// 子模块之间可以互相依赖(按需保留/收紧)
{ from: ["store-module"], allow: ["store-module"] },
// 门面可以依赖子模块(聚合/转发)
{ from: ["store-facade"], allow: ["store-module"] },
// ❗子模块禁止依赖"总 barrel"(避免 A→barrel→A 自咬尾)
{ from: ["store-module"], disallow: ["store-barrel"] },
// 其它层的约束(示例):UI 不能直接依赖 barrel(可选)
{ from: ["ui"], disallow: ["store-barrel"] },
],
},
],
/* 可选:统一扩展名策略,减少误判 */
"import/extensions": [
"error",
"ignorePackages",
{
ts: "never",
tsx: "never",
js: "never",
jsx: "never",
mjs: "never",
},
],
},
/* 可选但实用:严格禁止"子模块从 barrel 导入" */
overrides: [
{
files: ["src/store/**/*.ts"],
rules: {
"no-restricted-imports": [
"error",
{
paths: ["@/store", "@/store/index", "@/store/index.ts"],
patterns: [
{
group: ["@/store"],
message:
'子模块请使用相对路径或具体子模块路径导入,禁止从 "@/store"(barrel)导入。',
},
],
},
],
},
},
],
};TypeScript 别名配置
json
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@/store": ["./src/store/index.ts"], // 仅对外层暴露
"@/services/*": ["./src/services/*"]
}
}
}package.json 脚本
json
{
"scripts": {
"lint": "eslint \"src/**/*.{ts,tsx,js,vue}\"",
"lint:fix": "eslint \"src/**/*.{ts,tsx,js,vue}\" --fix"
}
}.eslintignore(可选)
gitignore
dist/
node_modules/
coverage/违规示例与修正
ts
// ❌ 会命中 boundaries + no-restricted-imports
import { useAccountStore } from "@/store"; // barrel
// ✅ 用具体路径/相对路径,避免循环依赖
import { useAccountStore } from "@/store/account";ts
// ❌ 子模块引用 barrel,极易形成 A→barrel→A
import { useTradeTabsStore } from "@/store";
// ✅ 门面引用子模块,用相对路径
import { useTradeTabsStore } from "./tabs.store";规则说明
import/no-cycle:兜底阻止环依赖boundaries:定义"元素类型 + 允许关系",从设计上约束依赖方向no-restricted-imports:对"子模块 →barrel"的坏味道一刀切禁止
使用建议:
- 外部组件:使用别名
import { useTradeStore } from '@/store' - 内部模块:使用相对路径
import { useTabsStore } from './tabs.store',更直观且避免循环依赖
小结:"外用 barrel、内走相对",再配合门面只做转发与编排,就能在大项目里稳住依赖关系与可维护性。
5. 事件/消息编排(可选)
大型团队会用轻量事件总线或「动作层」(如 services/tradeService.ts)来做跨 store 的复杂流程(下单 → 订阅 → 回补数据)。
好处:
- ✅ 把"过程"从 store 里抽离
- ✅ store 专注"状态"
- ✅ 业务流程可独立测试
6. 包/模块分层(Monorepo 可选)
超大项目会把领域 store 放到独立包: packages/state-account/、state-trade/、state-market/,应用项目只做聚合和 UI。
好处:
- ✅ 解耦团队协作与发布节奏
- ✅ 可独立版本管理
- ✅ 跨项目复用
目录结构示例
src/
store/
account/
index.ts # useAccountStore
symbol-info/
index.ts # useSymbolInfoStore
trade/
index.ts # useTradeStore - 聚合门面,只转发
tabs.store.ts # 具体功能模块
orders.store.ts
positions.store.ts
socket/
index.ts # useSocketStore
services/
trade.service.ts # 跨模块编排(可选)
composables/
useTradeBootstrap.ts # 初始化/监听装配(可选)聚合门面示例
ts
// store/trade/index.ts
import { useTradeTabsStore } from "./tabs.store";
import { useTradeOrdersStore } from "./orders.store";
import { useTradePositionsStore } from "./positions.store";
/**
* Trade 模块聚合门面
* 只做转发和跨模块编排,不包含具体业务逻辑
*/
export function useTradeStore() {
const tabsStore = useTradeTabsStore();
const ordersStore = useTradeOrdersStore();
const positionsStore = useTradePositionsStore();
// 转发各子模块的状态和方法
return {
// Tabs 相关
tabs: tabsStore.tabs,
selectedTab: tabsStore.selectedTab,
updateActiveSymbol: tabsStore.updateActiveSymbol,
// Orders 相关
orders: ordersStore.orders,
pendingOrders: ordersStore.pendingOrders,
// Positions 相关
positions: positionsStore.positions,
// 跨模块编排方法
reset() {
tabsStore.reset();
ordersStore.reset();
positionsStore.reset();
},
};
}Feature Store 示例
ts
// store/trade/tabs.store.ts
import { defineStore } from "pinia";
import { useSocketStore } from "@/store/socket";
import { useSymbolInfoStore } from "@/store/symbol-info";
export const useTradeTabsStore = defineStore("trade-tabs", () => {
const socketStore = useSocketStore();
const symbolInfoStore = useSymbolInfoStore();
const tabs = ref<SymbolTabItem[]>([]);
const selectedTab = ref<SymbolTabItem | null>(null);
// 切换选中 Tab
const updateActiveSymbol = (tab: SymbolTabItem) => {
if (selectedTab.value?.symbol === tab.symbol) return;
selectedTab.value = tab;
// 跨模块协作:更新订阅和获取规格
socketStore.setPinned(tab.symbol);
symbolInfoStore.fetchSpecs(tab.symbol);
};
// 重置
const reset = () => {
tabs.value = [];
selectedTab.value = null;
socketStore.setPinned(null);
};
return {
tabs,
selectedTab,
updateActiveSymbol,
reset,
};
});测试与稳定性
单元测试
小 store 粒度更小、状态面更窄 → 单测更快。
ts
// tabs.store.spec.ts
import { setActivePinia, createPinia } from "pinia";
import { useTradeTabsStore } from "./tabs.store";
describe("TradeTabsStore", () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it("should update selected tab", () => {
const store = useTradeTabsStore();
const tab = { symbol: "EURUSD" /* ... */ };
store.updateActiveSymbol(tab);
expect(store.selectedTab).toBe(tab);
});
it("should reset all state", () => {
const store = useTradeTabsStore();
store.tabs = [{ symbol: "EURUSD" }];
store.selectedTab = store.tabs[0];
store.reset();
expect(store.tabs).toEqual([]);
expect(store.selectedTab).toBeNull();
});
});契约测试
关键动作(如下单、切换账户、订阅行情)做契约测试(输入/输出约定),避免重构时回归。
ts
// trade.service.contract.spec.ts
describe("Trade Service Contract", () => {
it("should handle order flow correctly", async () => {
// 给定:初始状态
const initialState = {
/* ... */
};
// 当:执行下单
const result = await tradeService.placeOrder({
symbol: "EURUSD",
volume: 0.01,
type: "BUY",
});
// 那么:应该满足契约
expect(result).toMatchObject({
orderId: expect.any(String),
status: "pending" | "filled" | "rejected",
});
});
});性能与可维护性
性能优化
- ✅ 小 store 避免巨大的
state导致无关组件重渲染 - ✅ 热更新与代码分割更友好(按需引入某些 store)
- ✅ 可以针对高频更新的 store 做单独优化
可维护性提升
- ✅ 每个 store 职责单一,易于理解
- ✅ 修改影响范围可控,降低回归风险
- ✅ 新人上手成本低,可以逐个模块学习
- ✅ 便于增量重构,不影响其他模块
迁移路径(渐进式重构)
如果你现有项目是巨型 store,可以这样渐进式重构:
第一步:识别领域边界
分析现有 store 的状态和方法,按业务领域分组:
现有 useTradeStore:
- tabs 相关:tabs, selectedTab, updateActiveSymbol, ...
- orders 相关:orders, pendingOrders, placeOrder, ...
- positions 相关:positions, closePosition, ...第二步:抽取 Feature Store
先抽取一个耦合最少的模块(如 tabs):
ts
// 1. 创建新的 tabs.store.ts
export const useTradeTabsStore = defineStore("trade-tabs", () => {
// 把 tabs 相关的状态和方法搬过来
// ...
});
// 2. 在原 trade store 中引用
export const useTradeStore = defineStore("trade", () => {
const tabsStore = useTradeTabsStore();
return {
// 暂时保持原有 API,内部转发
tabs: computed(() => tabsStore.tabs),
selectedTab: computed(() => tabsStore.selectedTab),
updateActiveSymbol: tabsStore.updateActiveSymbol,
// ...
};
});第三步:逐步迁移调用方
ts
// 旧代码(暂时仍可工作)
const tradeStore = useTradeStore();
console.log(tradeStore.tabs);
// 新代码(逐步迁移)
const tabsStore = useTradeTabsStore();
console.log(tabsStore.tabs);第四步:清理遗留代码
当所有调用方都迁移完成后,删除 trade store 中的转发代码,完成重构。
实战建议
1. 命名规范
- 领域 store:
useXxxStore,如useAccountStore - Feature store:
useXxxYyyStore,如useTradeTabsStore - 聚合门面:保持简洁,如
useTradeStore
2. 状态共享原则
- 不要在一个 store 的
state中存储另一个 store 的引用 - 不要在 store 之间传递响应式对象(会破坏单向数据流)
- 应该通过方法调用来触发其他 store 的动作
- 应该使用 computed 来派生跨 store 的状态
3. 初始化时机
复杂的跨 store 初始化逻辑,放到独立的 composable:
ts
// composables/useTradeBootstrap.ts
export function useTradeBootstrap() {
const accountStore = useAccountStore();
const socketStore = useSocketStore();
const tabsStore = useTradeTabsStore();
const initialize = async () => {
await accountStore.fetchAccounts();
await socketStore.connect();
tabsStore.restoreFromLocalStorage();
};
onMounted(() => {
initialize();
});
}4. 调试与监控
导出关键 store 的状态到 devtools:
ts
// store/index.ts
export function setupDevtools() {
if (import.meta.env.DEV) {
window.__STORES__ = {
account: useAccountStore(),
socket: useSocketStore(),
tradeTabs: useTradeTabsStore(),
// ...
};
}
}结论
大型项目中的 Pinia Store 组织架构,推荐遵循:
- 按领域拆分:每个 store 单一职责
- 聚合门面:保持 API 稳定,内部持续演进
- Feature Store:页面复杂交互独立管理
- 避免循环依赖:在方法中按需调用其他 store
- 渐进式重构:从耦合最少的模块开始抽取
这种架构模式对团队协作和长期演进都更友好,是经过大厂验证的最佳实践。